本篇文章主要介绍 JVM 类加载机制。
类加载的过程
加载
加载 (Loading) 阶段是 类加载 (Class Loading) 过程的一个阶段。在加载阶段,虚拟机要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在 Java 堆中生成一个代表这个类的
java.lang.Class
对象,作为方法区这些数据的访问入口。
虚拟机规范的这三点要求实际上并不具体,因此虚拟机实现与具体应用的灵活度相当大。例如 “通过一个类的全限名来获取定义此类的二进制字节流”,并没有指明二进制流要从一个 Class
文件中获取,准确地说是根本没有指明要从哪里获取及怎样获取。虚拟机设计团队在加载阶段搭建了一个相对开放的、广阔的舞台,Java 发展历程中,充满创造力的开发人员们则在这个舞台上玩出了各种花样,许多举足轻重的 Java 技术都建立在这一基础之上,例如:
- 从 ZIP 包中读取,这很常见,最终成为日后的 JAR、EAR、WAR 格式的基础。
- 从网络中获取,这种场景最典型的应用就是 Applet。
- 运行时计算生成,这种场景使用得最多的就是 动态代理技术,在
java.lang.reflect.Proxy
中,就是用了ProxyGenerator.generateProxyClass
来为特定接口生成*$Proxy
的代理类的二进制字节流。 - 由其他文件生成,典型场景:JSP 应用。
- 从数据库中读取,这种场景相对少见些,有些中间件服务器 (如 SAP Netweaver) 可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
相对于类加载过程的其他阶段,加载阶段 (准确地说,是加载阶段中获取类的二进制字节流的动作) 是开发期可控性最强的阶段,因为加载阶段既可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员们可以通过定义自己的类加载器去控制字节流的获取方式。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机的实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在 Java 堆中实例化一个 java.lang.Class
类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。加载阶段与连接阶段的部分内容 (如一部分字节码文件格式验证动作) 是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class
文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java 语言本身是相对安全的语言 (对于 C/C++ 来说),使用纯粹的 Java 代码无法做到注入访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在代码行之类的事情,如果这样做了,编译器将拒绝编译。但前面已经说过,Class
文件并不一定要求用 Java 源码编译而来,可以使用任何途径,包括用十六进制编辑器直接编写来产生 Class
文件。在字节码的语言层面上,上述 Java 代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要的工作。
尽管验证阶段是非常重要的,并且验证阶段的工作量在虚拟机的类加载子系统中占了很大一部分,但虚拟机规范对这个阶段的限制和指导显得非常笼统,仅仅说了一句如果验证到输入的字节流不符合 Class
文件的存储格式,就抛出一个 java.lang.VerifyError
异常或其子类异常,具体应当检查哪些方面,如何检查,何时检查,都没有强制要求或明确说明,所以不同的虚拟机对类验证的实现可能会有所不同,但大致上都会完成下面四个阶段的检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证 。
1. 文件格式验证
第一阶段要验证字节流是否符合 Class
文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
- 是否以魔数
0xCAFEBABY
开头。 - 主、次版本号是否在当前虚拟机处理范围之内。
- 常量池的常量中是否有不被支持的常量类型 (检查常量
tag
标志)。 - 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
CONSTANT_Utf8_info
型的常量中是否有不符合UTF8
编码的数据。Class
文件中各个部分及文件本身是否有被删除的或附加的其他信息。
实际上第一阶段的验证点还远不止这些,上面这些知识从 HotSpot 虚拟机源码中摘抄的一小部分,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于字节流进行的,经过了这个阶段的验证之后,字节流才会进入内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。
2. 元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类 (除了
java.lang.Object
之外,所有的类都应当有父类)。 - 这个类的父类是否继承了不允许被继承的类 (被
final
修饰的类)。 - 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生了矛盾 (例如覆盖了父类的
final
字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。
3. 字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,只要工作是进行数据流和控制流分析。在第二阶段对元数据信息中的数据类型做完校验后,这阶段将对类的方法体进行校验分析。这阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈中放置了一个
int
类型的数据,使用时却按long
类型来加载入本地变量表中。 - 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检查,也不能保证这一点。这里涉及了离散数学中一个很著名的问题 Halting Problem
;通俗一点的说法就是,通过程序去校验程序逻辑是无法做到绝对准确的——不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。
由于数据流验证的高复杂性,虚拟机设计团队为了避免将过多的时间消耗在字节码验证阶段,在 JDK 1.6 之后的 Javac
编译器中进行了一项优化,给方法体的 Code
属性的属性表中增加了一项名为 StackMapTable
的属性,这项属性描述了方法体中所有的基本块 (Basic Block
,按照控制流拆分的代码块) 开始时本地变量表的操作栈应有的状态,这可以将字节码验证的类型推导转变为类型检查从而节省一些时间。当然,理论上 StackMapTable
属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了 Code
属性的同时,也生成相应的 StackMapTable
属性来骗过虚拟机的类型校验则是虚拟机实现是值得思考的问题。
在 JDK 1.6 的 HotSpot 虚拟机中提供了 -XX:-UseSplitVerifier
选项来关闭掉这项优化,或者使用参数 -XX:+FailOverToOldVerifier
要求在类型校验失败的时候退回到旧的类型推导方法进行校验。而在 JDK 1.7 之后,对于主版本号大于50的 Class
文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到类型推导的校验方式。
4. 符合引用验证
最后一个阶段的校验发生在虚拟机将符合引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外 (常量池中的各种符号引用) 的信息进行匹配性的校验,通常需要校验以下内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段和方法的访问性 (
private
、protected
、public
、default
) 是否可被当前类访问。
符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,将会抛出一个 java.lang.IncompatibleClassChangeError
异常的子类,如 java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但不一定是必要的阶段。如果所运行的全部代码 (包括自己写的、第三方包中的代码) 都已经被反复使用和验证过,在实施阶段就可以考虑使用 -Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先是这时候进行内存分配的仅包括类变量 (被 static
修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次是这里所说的初始值 “通常情况” 下是数据类型的零值,假设一个类变量的定义为:public static int value = 123
。那么变量 value
在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何 Java 方法,而把 value
赋值为123的 putstatic
指令是程序被编译后,存放于类构造器 <clinit>()
方法之中,所以把 value
赋值为123的动作将在初始化阶段才会被执行。下图列出了 Java 中所有基本数据类型的零值。
上面提到,在 “通常情况” 下初始值是零值,那相对的会有一些 “特殊情况”:如果类字段的字段属性表中存在 ConstantValue
属性,那么准备阶段变量 value
就会被初始化为 ConstantValue
属性所指定的值,假设上面类变量 value
的定义为:public static final int value = 123
,编译时 Javac
将会为 value
生成 ConstantValue
属性,在准备阶段虚拟机就会根据 ConstantValue
的设置将 value
赋值为123。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在 Class
文件中以 CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
- 符号引用 (Symbolic References): 符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
- 直接引用 (Direct References): 直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行 anewarray
、checkcast
、getfield
、getstatic
、instanceof
、invokeinterface
、invokespecial
、invokestatic
、invokevirtual
、multianewarray
、new
、putfield
和 putstatic
这13个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
对同一个符号引用进行多次解析请求时很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存 (在运行时常量池中记录直接引用,并把常量标识为已解析状态) 从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的都是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该受到相同的异常。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池的 CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
及 CONSTANT_InterfaceMethodref_info
四种常量类型。下面将讲解这四种引用的解析过程。
1. 类或接口的解析
假设当前代码所处的类为 D,如果要把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
- 如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于无数据验证、字节码验证的需要,又将可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
- 如果 C 是一个数组类型,并且数组的元素类型为对象,也就是 N 的描述符会是类似
[Ljva.lang.Integer
的形式,那将会按照第1点的规则加载数组元素类型。如果 N 的描述符如前面所假设的形式,需要加载的元素类型就是java.lang.Integer
,接着由虚拟机生成一个代表此数组维度和元素的数组对象。 - 如果上面的步骤没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认 C 是否具备对 D 的访问权限。如果发现不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
2. 字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段内 class_index
项中索引的 CONSTANT_Class_info
符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用 C 表示,虚拟机规范要求按照如下步骤对 C 进行后续字段的搜索:
- 如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果在 C 中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果 C 不是
java.lang.Object
的话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。 - 否则,查找失败,抛出
java.lang.NoSuchFieldError
异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出 java.lang.IllegalAccessError
异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范要求得更加严格一些,如果有一个同名字段同时出现在 C 的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。在下面代码中,如果注释了 Sub
类中的 public static int A = 4
,接口与父类同时存在字段 A,那编译器将提示 The field Sub.A is ambiguous
,并且会拒绝编译这段代码。
1 | 4j |
3. 类方法解析
类方法解析的第一步骤与字段解析一样,也是需要先解析出来类方法表的 class_index
项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用 C 表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:
- 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现
class_index
中索引的 C 是个接口,那就直接抛出java.lang.IncompatibleClassChangeError
异常。 - 如果通过了 第1步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,这时候查找结束,抛出
java.lang.AbstractMethodError
异常。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证;如果发现不具备对此方法的访问权限,将抛出 java.lang.IllegalAccessError
异常。
4. 接口方法解析
接口方法也是需要先解析出接口方法表的 class_index
项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
- 与类方法解析相反,如果在接口方法表中发现
class_index
中的索引 C 是个类而不是接口,那就直接抛出java.lang.IncomepatibleClassChangeError
异常。 - 否则,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在接口 C 的父接口中递归查找,直到
java.lang.Object
类 (查找范围会包括Object
类) 为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
由于接口中的所有方法都默认是 public
的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出 java.lang.IllegalAccessError
异常。
初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户程序可以通过自定义加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码 (或者说是字节码)。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>()
方法的过程。<clinit>()
方法执行过程中可能会影响程序运行行为的一些特点和细节:
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块 (static {}
块) 中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序锁决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。<clinit>()
方法与类的构造函数 (或者说实例构造器<init>()
方法) 不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()
方法执行之前,父类的<clinit>()
方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()
方法的类肯定是java.lang.Object
。由于父类的
<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下代码,字段 B 的值将会是2而不是1。1
2
3
4
5
6
7
8
9
10
11
12
13
14static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}<clinit>()
方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样会生成
<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()
方法完毕。如果在一个类的<clinit>()
方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。下面代码演示了这种场景。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
294j
public class DeadLoopClass {
static {
//如果不加上这个 if 判断,编译器将提示 "Initializer does not complete normally" 并拒绝编译
if (true) {
log.info("{} init DeadLoopClass", Thread.currentThread());
while (true) {
}
}
}
public static void main(String[] args) {
Runnable script = new Runnable() {
public void run() {
log.info("{} start", Thread.currentThread());
DeadLoopClass deadLoopClass = new DeadLoopClass();
log.info("{} run over", Thread.currentThread());
}
};
Thread thread1 = new Thread(script, "thread-1");
Thread thread2 = new Thread(script, "thread-2");
thread1.start();
thread2.start();
}
}
类加载器
虚拟机设计团队把类加载阶段中的 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块被称为 “类加载器”。
类加载器可以说是 Java 语言的一项创新,也是 Java 语言流行的重要原因之一,它最初是为了满足 Java Applet 的需求而被开发出来的。如今 Java Applet 技术基本上已经死掉,但类加载器却在 类层次划分、OSGi、热部署、代码加密 等领域大放异彩,成了为 Java 技术体系中一块重要的基石,真可谓是失之桑榆,收之东隅。
类与类加载器
类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性。这句话可以表达得更通俗一些:比较两个类是否 “相等”,只有在这两个类是由用一个类加载器加载的前提之下才有意义,否则,即使这两个类是来源于用一个 Class
文件,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的 “相等”,包括代表类的 Class
对象的 equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果,也包括了使用 instanceof
关键字做对象所属关系判定等情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,下面代码演示了不同的类加载器对 instanceof
关键字运算结果的影响。
1 | 4j |
运行结果:
上面代码构造了一个简单的类加载器,尽管很简陋,但是对于这个演示来说还是够用了。它可以加载与自己同一路径下的 Class
文件。我们使用这个类加载器去加载了一个名为 com.leisurexi.jvm.classloading.ClassLoaderTest
的类,并实例化了这个类的对象。两行输出结果中,从第一句可以看到这个对象确实是类 com.leisurexi.jvm.classloading.ClassLoaderTest
实例化出来的对象,但从第二句可以发现这个对象与类 com.leisurexi.jvm.classloading.ClassLoaderTest
做所属类型检查的时候却返回了 false
,这是因为虚拟机中存在了两个 ClassLoaderTest
类,一个是由 系统应用程序类加载器 加载的,另外一个是由我们 自定义的类加载器 加载的,虽然都来自同一个 Class
文件,但依然是两个独立的类,故对象所属类型检查时结果自然为 false
。
双亲委派模型
站在 Java 虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类加载器 (Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
。
从 Java 开发人员的角度来看,类加载器就还可以划分得更细致一下,绝大部分 Java 程序都会使用到以下三种系统提供的类加载器:
- 启动类加载器 (Boostrap ClassLoader): 前面已经介绍过,这个类加载器负责将存放在
<JAVA_HOME>\bin
目录中的,或者被-Xbootclasspath
参数所指定的路径中的,并且是虚拟机识别的 (仅按照文件名识别,如rt.jar
,名字不符合的类库即使放在lib
目录中也不会被加载) 类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用。 - 扩展类加载器 (Extension ClassLoader): 这个加载器由
sun.misc.Launcher$ExtClassLoader
实现,它负责加载<JAVA_HOME>\bin\ext
目录中的,或者被java.ext.dirs
系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 - 应用程序类加载器 (Application ClassLoader): 这个类加载器由
sun.misc.Launcher$AppClassLoader
来实现。由于这个类加载器是ClassLoader
中的getSystemClassLoader()
方法的返回值,所以一般也称它为 系统类加载器。它负责加载用户类路径 (ClassPath) 上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般会如下图所示。
上图所展示的类加载器之间的这种层次关系,就称为类加载器的 双亲委派模型 (Parents Delegation Model) 。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承 (Inheritance) 的关系来实现,而是都使用组合 (Composition) 关系来复用父加载器的代码。
类加载器的双亲委派模型在 JDK 1.2 期间被引入并被广泛应用于之后几乎所有的 Java 程序中,但它并不是一个强制性的约束模型,而是 Java 设计者们推荐给开发者们的一种类加载器实现方式。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求 (它的搜索范围中没有找到所需的类) 时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object
,它存放在 rt.jar
之中,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,因此 Object
类在程序的各种类加载器环境中都是用一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己写了一个名为 java.lang.Object
的类,并放在程序的 ClassPath
中,那系统中将会出现多个不同的 Object
类,Java 类型体系中最基础的行为也就无从保证,应用程序也将会变得一片混乱。
双亲委派模型对于保证 Java 程序的稳定运作很重要,但它的显现却非常简单,实现双亲委派的代码都集中在 java.lang.ClassLoader
的 loadClass()
方法之中,如下面代码所示,逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass()
方法,若父加载器为空则默认使用启动类加载器作为父加载器。 如果父类加载失败,则在抛出 ClassNotFoundException
异常后,再调用自己的 findClass()
方法进行加载。
1 | protected synchronized Class<?> loadClass(String name, boolean resolev) throws ClassNotFoundException { |
破坏双亲委派模型
上文提到过双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者们推荐给开发者们的类加载器实现方式。在 Java 的世界里面大部分的类加载器都遵循这个模型,但也有例外的情况,到现在为止,双亲委派模型主要出现过三次较大规模的 “被破坏” 情况。
双亲委派模型的第一次 “被破坏” 其实发生在双亲委派模型出现之前——即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入的,而类加载器和抽象类 java.lang.ClassLoader
则在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者们引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader
添加了一个新的 protected
方法 findClass()
,在此之前,用户去继承 java.lang.ClassLoader
的唯一目的就是为了重写 loadClass()
方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法 loadClassInternal()
,而这个方法的唯一逻辑就是去调用自己的 loadClass()
。
双亲委派的具体逻辑实现在 loadClass()
方法中,所以 JDK 1.2 之后不提倡用户再去覆盖 loadClass()
方法,而应当把自己的类加载逻辑写到 findClass()
方法中,在 loadClass()
方法的逻辑里如果父类加载失败,则会调用自己的 findClass()
方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。
双亲委派模型的第二次 “被破坏” 是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题 (越基础的类由越上层的加载器进行加载),基础类之所以被称为 “基础”,是因为它们总是作为被用户代码调用的 API,但世事往往没有绝对的完美,如果基础类又要调用用户的代码,那该怎么办了?
这并非是不可能的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码有启动类加载器去加载 (在 JDK 1.3 时代放进去的 rt.jar
),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath
下的 JNDI 接口提供者 (SPI,Service Provider Interface) 的代码,但启动类加载器不可能 “认识” 这些代码啊!那该怎么办?
为了解决这个困境,Java 设计团队只好引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以用过 java.lang.Thread
类的 setContextClassLoader()
方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是 应用程序类加载器。
有了线程上下文类加载器,就可以做一些 “舞弊” 的事情了,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
双亲委派模型的第三次 “被破坏” 是由于用户对程序动态性的追求而导致的,这里所说的 “动态性” 指的是当前一些非常 “热” 们的名词:代码热替换 (HotSwap)、模块热部署 (Hot Deployment) 等, 说白了就是希望应用程序能像我们的电脑外设那样,插上鼠标或U盘,不同重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不同停机也不用重启。
在 JSR-297、JSR-277 规范从纸上标准变成真正可运行的程序之前,OSGi 是当前业界 “事实上” 的 Java 模块化标准,而 OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块 (OSGi 中称为 Bundle) 都有一个自己的类加载器,当需要更换一个 Bundle
时,就把 Bundle
连同类加载器一起换掉以实现代码的热替换。
在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:
- 将以
java.*
开头的类,委派给父类加载器加载。 - 否则,将委派列表名单内的类,委派给父类加载器加载。
- 否则,将
Import
列表中的类,委派给Export
这个类的Bundle
的类加载器加载。 - 否则,查找当前
Bundle
的ClassPath
,使用自己的类加载器加载。 - 否则,查找类是否在自己的
Fragment Bundle
中,如果在,则委派给Fragment Bundle
的类加载器加载。 - 否则,查找
Dynamic Import
列表的Bundle
,委派给对应Bundle
的类加载器加载。 - 否则,类查找失败。
上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的。
参考
《深入理解Java虚拟机》—— 周志明